Terraform で API Gateway(REST API)を構築する
今回は Terraform で API Gateway を構築する機会がありましたので、ハマりポイントなどを含めて紹介いたします。
Terraform で作る API Gateway 環境
今回の環境ですが、以下のような構成を想定しています。API Gateway のバックエンドは NLB を経由して ECS タスクへとリクエストが流れます。本記事で作成するのは、枠で囲んだ部分です。
もう少し詳細にご紹介すると、この記事をとおして Terraform で構築する API Gateway は以下のとおり。認証なども特に設定していない簡易的なものです。
- API Gateway
- REST API
- メソッドおよびインテグレーション(OpenAPI でインポート)
- ステージ
- デプロイ
- リソースポリシー(OpenAPI でインポート)
- VPC リンク
- カスタムドメイン
- ベースパスマッピング
一方、以下のリソースはリソース参照部分の記載がありますが、既にあるものとして仮定しています。(参照先のリソース作成部分のコードは記載していません)
- NLB
- Route53
実行環境
今回、検証で利用した実行環境は下記のとおりです。
$ terraform version Terraform v0.12.29 + provider.aws v3.5.0
Terraform で API Gateway を構築するぞ!
VPC リンクの作成
はじめに、今回は VPC 内の ECS コンテナにリクエストを送るため、VPC リンクを作成します。REST API で VPC リンクに利用できる ELB は NLB のみです。
resource "aws_api_gateway_vpc_link" "vpclink" { name = "vpc_link" target_arns = [aws_lb.nlb.arn] }
OpenAPI ファイルの準備(メソッドおよびインテグレーションの定義)
メソッドやインテグレーションの作成は aws_api_gateway_method
および aws_api_gateway_integration
など Terraform(HCL) で書くこともできますが、今回は OpenAPI を事前に作成しておきインポートする方法で構築します。
以下は /
に対して GET
メソッドおよび、VPC リンク先へのプロキシを定義した、とても単純な OpenAPI の定義ファイルです。OpenAPI 仕様に対する API Gateway 拡張は x-amazon-apigateway-xxx
で記述します。詳細は公式ガイドを参照ください。
openapi: "3.0.1" info: title: "test_api" version: "2020-09-09T06:11:13Z" paths: /: get: responses: 200: description: "200 response" content: application/json: schema: $ref: "#/components/schemas/Empty" x-amazon-apigateway-integration: uri: "${nlb_uri}" responses: default: statusCode: "200" passthroughBehavior: "when_no_match" connectionType: "VPC_LINK" connectionId: "${vpc_link}" httpMethod: "GET" type: "http_proxy"
上記 .yaml
ファイルを aws_api_gateway_rest_api
が参照するためデータリソースとして定義します。vars
で環境変数を渡すことも可能です。今回は VPC リンク ID および、NLB の URI を環境変数に指定しました。
data "template_file" "openapi" { template = "${file("./OpenAPI/test_api-dev-apigateway.yaml")}" vars = { vpc_link = aws_api_gateway_vpc_link.vpclink.id nlb_uri = "http://${aws_lb.nlb.dns_name}" } }
構築後の API Gateway からエクスポートした .yaml
ファイルを再利用する場合は ${}
部分が実際の値に置き換わっているのでご注意ください。
REST API 定義
先程の template_file
を指定して、REST API を定義します。name
は OpenAPI 内の title
に置き換えられますが差があると毎回 terraform plan
の差分になってしまうので揃えます。今回は REGIONAL
エンドポイントで作成しています。
resource "aws_api_gateway_rest_api" "api" { name = "test_api" body = data.template_file.openapi.rendered endpoint_configuration { types = ["REGIONAL"] } lifecycle { ignore_changes = [ policy ] } }
本記事ではリソースポリシーを後ほど OpenAPI 内で定義する手順となっています。Terraform(HCL)では記述しないため、ポリシー定義後に設定差分として検出されないように ignore_changes
に指定しておきます。
ステージとデプロイメント定義
ステージの定義は aws_api_gateway_stage
リソースがありますが、aws_api_gateway_deployment
だけの指定でも stage_name
に基づいてステージが作成されます。
アクセスログ、キャッシュ、X-Ray トレーシングなどなど、ステージの詳細設定が必要であれば aws_api_gateway_stage
を個別に定義してください。今回は簡易設定として、aws_api_gateway_deployment
のみで定義しています。
resource "aws_api_gateway_deployment" "deployment" { depends_on = [aws_api_gateway_rest_api.api] rest_api_id = aws_api_gateway_rest_api.api.id stage_name = "dev" triggers = { redeployment = "v0.1" } lifecycle { create_before_destroy = true } }
今回のキモは triggers
と create_before_destroy
です。
triggers
OpenAPI の内容や、その他 REST API 設定を変更し terraform apply
すると定義情報は更新されますが再デプロイはされません。再デプロイをトリガーするための設定が triggers = {redeployment = "xxx"}
になります。
上記の例では単純に v0.1
のような値を与えていますので、v0.2
に変更すると再デプロイがトリガーされます。
いちいち値を変更するのが面倒だしスマートじゃない、という場合は .tf
や OpenAPI ファイルのハッシュ値を指定することもできます。ファイル内容の変更によってハッシュ値が変わりますので、わざわざ値を変更せずとも再デプロイをトリガーします。
triggers = { redeployment = sha1(file("./OpenAPI/xxx.yaml")) }
create_before_destroy
create_before_destroy
は既存のリソースがあった場合に、先に削除してから作り直すことを指示します。
ベースパスマッピングを利用していない場合、この指定がなくても再デプロイはうまく動作しますが、ベースパスマッピングがある場合、create_before_destroy
の指定がないと、以下のようなエラーになります。
Error: error deleting API Gateway Deployment (xxxxxx): BadRequestException: Active stages pointing to this deployment must be moved or deleted
リソースポリシー定義
リソースポリシーは data "aws_iam_policy_document"
を使って定義することも出来ますが、今回は OpenAPI 内で x-amazon-apigateway-policy
を追加し、再デプロイで設定することにしました。(参考:x-amazon-apigateway-policy)
初回のデプロイ後、execution_arn
が確定しますので、以下のような記述で API Gateway の呼び出し元を制限することができます。
(最下行に追加) x-amazon-apigateway-policy: Version: "2012-10-17" Statement: - Sid: "" Effect: "Allow" Principal: AWS: "*" Action: "execute-api:Invoke" Resource: "arn:aws:execute-api:ap-northeast-1:123456789012:7c7dxz6gh7/*" Condition: IpAddress: aws:SourceIp: - "58.xx.xx.xx/32"
カスタムドメイン定義
カスタムドメインに使用するドメインおよび、ACM 証明書は別途、設定されていることを前提とします。ちなみに、今回は REGIONAL
ですので ACM 証明書は対象リージョンの東京に作成済みです。EDGE
の場合は CloudFront 同様にバージニアで作成してください。
resource "aws_api_gateway_domain_name" "domain" { domain_name = "api.marumo.classmethod.info" regional_certificate_arn = aws_acm_certificate_validation.retrieval.certificate_arn endpoint_configuration { types = ["REGIONAL"] } }
Terraform で ACM 無料証明書発行される場合は、以下の記事を参照ください。
ベースパスマッピング定義
aws_api_gateway_domain_name
ではカスタムドメインを定義しただけです。次にベースパスマッピングを定義し、カスタムドメインと呼び出し先のステージを紐付けます。
resource "aws_api_gateway_base_path_mapping" "base_path" { depends_on = [aws_api_gateway_deployment.deployment] api_id = aws_api_gateway_rest_api.api.id stage_name = aws_api_gateway_deployment.deployment.stage_name domain_name = aws_api_gateway_domain_name.domain.domain_name }
動作確認
上記のとおり Terraform で作成した API Gateway をコールして動作確認してみましょう。まずは、リソースポリシーで許可されたアクセス元からの API コール。
$ curl https://checkip.amazonaws.com/ 58.xx.xx.xx $ curl https://api.marumo.classmethod.info/ Hello,Classmethod!
次に、許可されていないアクセス元からの API コール。
$ curl https://checkip.amazonaws.com/ 54.xx.xx.xx $ curl https://api.marumo.classmethod.info/ {"Message":"User: anonymous is not authorized to perform: execute-api:Invoke on resource: arn:aws:execute-api:ap-northeast-1:********3583:7c7dxz6gh7/dev/GET/"}
期待したとおりの動きですね。これでアクセス元を制限した REST API を Terraform でデプロイできるこを確認できました。
さいごに
API Gateway の IaC には AWS SAM や Serverless Framework、最近だと CDK あたりを利用されることが多いとは思います。適材適所で IaC のツールを使い分けるのが正解だとは思いますが、とはいえ、これから IaC をはじめる方にとって幾つものツールをリソース毎に使い分けるには学習コストもそれなりに高くなるでしょう。
正直、Terraform で API Gateway を構築、運用する情報はあまり多くないのですが、「ようやく手に馴染んできた Terraform で出来る限り管理したいんや!」という方も少なくないかと思いましたので試してみました。
今回はかなりシンプルな API Gateway 定義しかしていないため、判断するには難しいですが触ってみた感じでは Terraform でも十分に管理できるんじゃないか、という気がいたします。もちろん先に述べたようなツールと比較するとコード量は多いのですが。。
(「いやいや、やってみたけどココが辛かった」「おい、やめとけ。そっちは修羅の国やぞ」という知見をお持ちの方がおられましたらフィードバックください!)
以上!大阪オフィスの丸毛(@marumo1981)でした!
サンプル
./OpenAPI/rest_api-dev-apigateway.yaml
openapi: "3.0.1" info: title: "test_api" version: "2020-09-09T06:11:13Z" paths: /: get: responses: 200: description: "200 response" content: application/json: schema: $ref: "#/components/schemas/Empty" x-amazon-apigateway-integration: uri: "${nlb_uri}" responses: default: statusCode: "200" passthroughBehavior: "when_no_match" connectionType: "VPC_LINK" connectionId: "${vpc_link}" httpMethod: "GET" type: "http_proxy" x-amazon-apigateway-policy: Version: "2012-10-17" Statement: - Sid: "" Effect: "Allow" Principal: AWS: "*" Action: "execute-api:Invoke" Resource: "arn:aws:execute-api:ap-northeast-1:123456789012:7c7dxz6gh7/*" Condition: IpAddress: aws:SourceIp: - "58.xx.xx.xx/32"
./api-gateway.tf
resource "aws_api_gateway_vpc_link" "vpclink" { name = "vpc_link" target_arns = [aws_lb.nlb.arn] } data "template_file" "openapi" { template = "${file("./OpenAPI/test_api-dev-apigateway.yaml")}" vars = { vpc_link = aws_api_gateway_vpc_link.vpclink.id nlb_uri = "http://${aws_lb.nlb.dns_name}" } } resource "aws_api_gateway_rest_api" "api" { name = "test_api" body = data.template_file.openapi.rendered endpoint_configuration { types = ["REGIONAL"] } lifecycle { ignore_changes = [ policy ] } } resource "aws_api_gateway_deployment" "deployment" { depends_on = [aws_api_gateway_rest_api.api] rest_api_id = aws_api_gateway_rest_api.api.id stage_name = "dev" triggers = { redeployment = "v0.1" } lifecycle { create_before_destroy = true } } resource "aws_api_gateway_domain_name" "domain" { domain_name = "api.marumo.classmethod.info" regional_certificate_arn = aws_acm_certificate_validation.retrieval.certificate_arn endpoint_configuration { types = ["REGIONAL"] } } resource "aws_api_gateway_base_path_mapping" "base_path" { depends_on = [aws_api_gateway_deployment.deployment] api_id = aws_api_gateway_rest_api.api.id stage_name = aws_api_gateway_deployment.deployment.stage_name domain_name = aws_api_gateway_domain_name.domain.domain_name }